🎖️GitЯра🎖️
Commit a6057f594dee69b0cbfbe7ce662a7d4d53c62f65
Parents : f0f12fa
Author : James Rich <james.a.rich@gmail.com>
Date : 2026-05-13T13:41:38-05:00
fix: localize a11y strings, stable keys, 16dp compact icons
M4: Extract NodeDescriptionStrings data class with rememberNodeDescriptionStrings()
composable resolver. buildNodeDescription now takes localized strings param
instead of hardcoded English. Added 9 a11y_node_* string resources.
M1: Replace mutableListOf<@Composable> with keyed Pair<String, @Composable>
list in CompactCombinedRow. Each item gets a stable key() wrapper for
correct Compose identity across recompositions.
M2: Bump compact icon size from 14dp to 16dp (M3 minimum for dense UI).
Extract COMPACT_ICON_SIZE_DP constant for consistency.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Changes
6 files changed, 180 insertions(+), 69 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index ae18175456..26d38fc936 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1,5 +1,15 @@
+### A11Y ###
a11y_label_value
a11y_message_from
+a11y_node_battery
+a11y_node_distance_away
+a11y_node_favorite
+a11y_node_hops_away
+a11y_node_last_heard
+a11y_node_offline
+a11y_node_online
+a11y_node_role
+a11y_node_signal
accept
acknowledgements
### ACTION ###
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 927209f025..bec869be79 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -16,8 +16,18 @@
-->
<resources>
+ <!-- A11Y -->
<string name="a11y_label_value">%1$s: %2$s</string>
<string name="a11y_message_from">Message from %1$s: %2$s</string>
+ <string name="a11y_node_battery">battery %1$d%%</string>
+ <string name="a11y_node_distance_away">%1$s away</string>
+ <string name="a11y_node_favorite">favorite</string>
+ <string name="a11y_node_hops_away">%1$d hops away</string>
+ <string name="a11y_node_last_heard">last heard %1$s</string>
+ <string name="a11y_node_offline">offline</string>
+ <string name="a11y_node_online">online</string>
+ <string name="a11y_node_role">role %1$s</string>
+ <string name="a11y_node_signal">signal %1$s</string>
<string name="accept">Accept</string>
<string name="acknowledgements">Acknowledgements</string>
<!-- ACTION -->
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/BuildNodeDescription.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/BuildNodeDescription.kt
index 1adf40fffd..d3b7c0aff5 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/BuildNodeDescription.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/BuildNodeDescription.kt
@@ -16,7 +16,20 @@
*/
package org.meshtastic.feature.node.component
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.a11y_node_battery
+import org.meshtastic.core.resources.a11y_node_distance_away
+import org.meshtastic.core.resources.a11y_node_favorite
+import org.meshtastic.core.resources.a11y_node_hops_away
+import org.meshtastic.core.resources.a11y_node_last_heard
+import org.meshtastic.core.resources.a11y_node_offline
+import org.meshtastic.core.resources.a11y_node_online
+import org.meshtastic.core.resources.a11y_node_role
+import org.meshtastic.core.resources.a11y_node_signal
import org.meshtastic.core.ui.component.determineSignalQuality
import org.meshtastic.core.ui.util.formatAgo
@@ -24,6 +37,34 @@ private const val MILLIS_PER_SECOND = 1000L
private const val MAX_BATTERY_PERCENT = 100
private const val SNR_UNSET_THRESHOLD = 100f
+/** Pre-resolved localized strings for TalkBack node descriptions. */
+@Immutable
+internal data class NodeDescriptionStrings(
+ val online: String,
+ val offline: String,
+ val favorite: String,
+ val lastHeard: String,
+ val role: String,
+ val hopsAway: String,
+ val battery: String,
+ val distanceAway: String,
+ val signal: String,
+)
+
+/** Resolves [NodeDescriptionStrings] from Compose string resources. */
+@Composable
+internal fun rememberNodeDescriptionStrings(): NodeDescriptionStrings = NodeDescriptionStrings(
+ online = stringResource(Res.string.a11y_node_online),
+ offline = stringResource(Res.string.a11y_node_offline),
+ favorite = stringResource(Res.string.a11y_node_favorite),
+ lastHeard = stringResource(Res.string.a11y_node_last_heard, "%s"),
+ role = stringResource(Res.string.a11y_node_role, "%s"),
+ hopsAway = stringResource(Res.string.a11y_node_hops_away, 0),
+ battery = stringResource(Res.string.a11y_node_battery, 0),
+ distanceAway = stringResource(Res.string.a11y_node_distance_away, "%s"),
+ signal = stringResource(Res.string.a11y_node_signal, "%s"),
+)
+
/** Builds a TalkBack-friendly description aggregating node state. Shared between [NodeItem] and [NodeItemCompact]. */
@Suppress("LongParameterList")
internal fun buildNodeDescription(
@@ -38,11 +79,16 @@ internal fun buildNodeDescription(
snr: Float,
rssi: Int,
viaMqtt: Boolean,
+ strings: NodeDescriptionStrings,
lastHeardIsRelative: Boolean = true,
): String = buildString {
append(name)
- append(if (isOnline) ", online" else ", offline")
- if (isFavorite) append(", favorite")
+ append(", ")
+ append(if (isOnline) strings.online else strings.offline)
+ if (isFavorite) {
+ append(", ")
+ append(strings.favorite)
+ }
if (lastHeard > 0) {
val timeText =
if (lastHeardIsRelative) {
@@ -50,14 +96,28 @@ internal fun buildNodeDescription(
} else {
DateFormatter.formatDateTime(lastHeard.toLong() * MILLIS_PER_SECOND)
}
- append(", last heard $timeText")
+ append(", ")
+ append(strings.lastHeard.replace("%s", timeText))
+ }
+ append(", ")
+ append(strings.role.replace("%s", role))
+ if (hopsAway > 0) {
+ append(", ")
+ append(strings.hopsAway.replace("0", hopsAway.toString()))
+ }
+ batteryLevel?.let {
+ if (it in 1..MAX_BATTERY_PERCENT) {
+ append(", ")
+ append(strings.battery.replace("0", it.toString()))
+ }
+ }
+ distance?.let {
+ append(", ")
+ append(strings.distanceAway.replace("%s", it))
}
- append(", role $role")
- if (hopsAway > 0) append(", $hopsAway hops away")
- batteryLevel?.let { if (it in 1..MAX_BATTERY_PERCENT) append(", battery $it%") }
- distance?.let { append(", $it away") }
if (hopsAway == 0 && !viaMqtt && snr < SNR_UNSET_THRESHOLD && rssi < 0) {
val quality = determineSignalQuality(snr, rssi)
- append(", signal ${quality.name.lowercase()}")
+ append(", ")
+ append(strings.signal.replace("%s", quality.name.lowercase()))
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index dd7e7664dd..8b1cf56a2c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -157,8 +157,9 @@ fun NodeItem(
}
}
+ val a11yStrings = rememberNodeDescriptionStrings()
val nodeDescription =
- remember(thatNode) {
+ remember(thatNode, a11yStrings) {
buildNodeDescription(
name = originalLongName,
isOnline = thatNode.isOnline,
@@ -171,6 +172,7 @@ fun NodeItem(
snr = thatNode.snr,
rssi = thatNode.rssi,
viaMqtt = thatNode.viaMqtt,
+ strings = a11yStrings,
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItemCompact.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItemCompact.kt
index 162c6cbbc0..3ffdc1c282 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItemCompact.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItemCompact.kt
@@ -37,6 +37,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -85,6 +86,7 @@ private const val LINE_COUNT_BASE = 1
private const val CHIP_MIN_DP = 36
private const val CHIP_MAX_DP = 70
private const val CHIP_PER_LINE_DP = 24
+private const val COMPACT_ICON_SIZE_DP = 16
@Composable
@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod")
@@ -145,8 +147,9 @@ fun NodeItemCompact(
val style = if (thatNode.isUnknownUser) FontStyle.Italic else FontStyle.Normal
+ val a11yStrings = rememberNodeDescriptionStrings()
val nodeDescription =
- remember(thatNode, lastHeardIsRelative) {
+ remember(thatNode, lastHeardIsRelative, a11yStrings) {
buildNodeDescription(
name = longName,
isOnline = thatNode.isOnline,
@@ -159,6 +162,7 @@ fun NodeItemCompact(
snr = thatNode.snr,
rssi = thatNode.rssi,
viaMqtt = thatNode.viaMqtt,
+ strings = a11yStrings,
lastHeardIsRelative = lastHeardIsRelative,
)
}
@@ -218,7 +222,7 @@ fun NodeItemCompact(
imageVector =
if (thatNode.isOnline) MeshtasticIcons.Success else MeshtasticIcons.DeviceSleep,
contentDescription = null,
- modifier = Modifier.size(14.dp),
+ modifier = Modifier.size(COMPACT_ICON_SIZE_DP.dp),
tint =
if (thatNode.isOnline) {
MaterialTheme.colorScheme.tertiary
@@ -308,66 +312,77 @@ private fun CompactCombinedRow(
showTelemetry: Boolean,
contentColor: Color,
) {
- val items = mutableListOf<@Composable () -> Unit>()
+ val items =
+ buildList<Pair<String, @Composable () -> Unit>> {
+ // Distance + Bearing
+ if (showLocation && distance != null && !isThisNode) {
+ add("distance" to { DistanceInfo(distance = distance, contentColor = contentColor) })
+ }
- // Distance + Bearing
- if (showLocation && distance != null && !isThisNode) {
- items.add { DistanceInfo(distance = distance, contentColor = contentColor) }
- }
+ // Hops Away (only when hopsAway > 0)
+ if (showHops && thatNode.hopsAway > 0) {
+ add("hops" to { HopsInfo(hops = thatNode.hopsAway, contentColor = contentColor) })
+ }
- // Hops Away (only when hopsAway > 0)
- if (showHops && thatNode.hopsAway > 0) {
- items.add { HopsInfo(hops = thatNode.hopsAway, contentColor = contentColor) }
- }
+ // Signal (direct only: hopsAway == 0, snr valid, not via MQTT)
+ val hasDirectSignal =
+ thatNode.hopsAway == 0 && thatNode.snr < 100f && !thatNode.viaMqtt && thatNode.rssi < 0
+ if (showSignal && hasDirectSignal) {
+ val quality = determineSignalQuality(thatNode.snr, thatNode.rssi)
+ add(
+ "signal" to
+ {
+ IconInfo(
+ icon = vectorResource(quality.icon),
+ contentDescription = stringResource(quality.nameRes),
+ contentColor = quality.color.invoke(),
+ text = stringResource(quality.nameRes),
+ )
+ },
+ )
+ }
- // Signal (direct only: hopsAway == 0, snr valid, not via MQTT)
- val hasDirectSignal = thatNode.hopsAway == 0 && thatNode.snr < 100f && !thatNode.viaMqtt && thatNode.rssi < 0
- if (showSignal && hasDirectSignal) {
- val quality = determineSignalQuality(thatNode.snr, thatNode.rssi)
- items.add {
- IconInfo(
- icon = vectorResource(quality.icon),
- contentDescription = stringResource(quality.nameRes),
- contentColor = quality.color.invoke(),
- text = stringResource(quality.nameRes),
- )
- }
- }
+ // Channel (only when > 0)
+ if (showChannel && thatNode.channel > 0) {
+ add("channel" to { ChannelInfo(channel = thatNode.channel, contentColor = contentColor) })
+ }
- // Channel (only when > 0)
- if (showChannel && thatNode.channel > 0) {
- items.add { ChannelInfo(channel = thatNode.channel, contentColor = contentColor) }
- }
+ // Device Role with conditional icons (unmessageable, MQTT)
+ if (showRole) {
+ add(
+ "role" to
+ {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ RoleInfo(role = thatNode.user.role, contentColor = contentColor)
+ if (unmessageable) {
+ Icon(
+ imageVector = MeshtasticIcons.Unmessageable,
+ contentDescription = null,
+ modifier = Modifier.size(COMPACT_ICON_SIZE_DP.dp),
+ tint = contentColor,
+ )
+ }
+ if (thatNode.viaMqtt) {
+ Icon(
+ imageVector = MeshtasticIcons.MqttConnected,
+ contentDescription = null,
+ modifier = Modifier.size(COMPACT_ICON_SIZE_DP.dp),
+ tint = contentColor,
+ )
+ }
+ }
+ },
+ )
+ }
- // Device Role with conditional icons (unmessageable, MQTT)
- if (showRole) {
- items.add {
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
- RoleInfo(role = thatNode.user.role, contentColor = contentColor)
- if (unmessageable) {
- Icon(
- imageVector = MeshtasticIcons.Unmessageable,
- contentDescription = null,
- modifier = Modifier.size(14.dp),
- tint = contentColor,
- )
- }
- if (thatNode.viaMqtt) {
- Icon(
- imageVector = MeshtasticIcons.MqttConnected,
- contentDescription = null,
- modifier = Modifier.size(14.dp),
- tint = contentColor,
- )
- }
+ // Telemetry log icons
+ if (showTelemetry && hasTelemetryData(thatNode)) {
+ add("telemetry" to { CompactTelemetryIcons(thatNode = thatNode, contentColor = contentColor) })
}
}
- }
-
- // Telemetry log icons
- if (showTelemetry && hasTelemetryData(thatNode)) {
- items.add { CompactTelemetryIcons(thatNode = thatNode, contentColor = contentColor) }
- }
if (items.isNotEmpty()) {
Row(
@@ -375,11 +390,11 @@ private fun CompactCombinedRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- items.forEachIndexed { index, item ->
+ items.forEachIndexed { index, (itemKey, content) ->
if (index > 0) {
VerticalDivider(modifier = Modifier.fillMaxHeight())
}
- item()
+ key(itemKey) { content() }
}
}
}
@@ -392,7 +407,7 @@ private fun CompactTelemetryIcons(thatNode: Node, contentColor: Color) {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = null,
- modifier = Modifier.size(14.dp),
+ modifier = Modifier.size(COMPACT_ICON_SIZE_DP.dp),
tint = contentColor,
)
}
@@ -400,7 +415,7 @@ private fun CompactTelemetryIcons(thatNode: Node, contentColor: Color) {
Icon(
imageVector = MeshtasticIcons.Temperature,
contentDescription = null,
- modifier = Modifier.size(14.dp),
+ modifier = Modifier.size(COMPACT_ICON_SIZE_DP.dp),
tint = contentColor,
)
}
@@ -408,7 +423,7 @@ private fun CompactTelemetryIcons(thatNode: Node, contentColor: Color) {
Icon(
imageVector = MeshtasticIcons.ElectricPower,
contentDescription = null,
- modifier = Modifier.size(14.dp),
+ modifier = Modifier.size(COMPACT_ICON_SIZE_DP.dp),
tint = contentColor,
)
}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/component/BuildNodeDescriptionTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/component/BuildNodeDescriptionTest.kt
index bfc79dcb63..af41eea3e6 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/component/BuildNodeDescriptionTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/component/BuildNodeDescriptionTest.kt
@@ -24,6 +24,19 @@ import kotlin.test.assertTrue
@Suppress("MagicNumber")
class BuildNodeDescriptionTest {
+ private val testStrings =
+ NodeDescriptionStrings(
+ online = "online",
+ offline = "offline",
+ favorite = "favorite",
+ lastHeard = "last heard %s",
+ role = "role %s",
+ hopsAway = "0 hops away",
+ battery = "battery 0%",
+ distanceAway = "%s away",
+ signal = "signal %s",
+ )
+
private fun describe(
name: String = "TestNode",
isOnline: Boolean = true,
@@ -49,6 +62,7 @@ class BuildNodeDescriptionTest {
snr = snr,
rssi = rssi,
viaMqtt = viaMqtt,
+ strings = testStrings,
lastHeardIsRelative = lastHeardIsRelative,
)
Served by rngit 1.5.0 - Generated in 0.19s